iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Build on Google AI

給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手系列 第 8 篇

Day 08|家屬不再瞎擔心!整合 LINE Messaging API 實現遠端用藥關懷推播

  • 分享至 

  • xImage
  •  

✏️【本日實作紀錄:LINE Messaging API 串接與家屬關懷推播】

今天進入遠端關懷與推播通知模組,完成項目包含:

  • 申請與設定 LINE Developer Console — 建立 Messaging API Channel 並取得 Channel Access Token 與 Channel Secret。
  • 整合 line-bot-sdk 至 Flask 後端 — 新增 LINE 推播模組,讓長輩完成藥袋解析後,自動發送格式化的 Flex Message 卡片通知至家屬 LINE。
  • 重構 /analyze-prescription 端點 — 在 SQLite 寫入與語音生成完成後,同步觸發遠端關懷推播功能。

一、 為什麼需要 LINE 遠端關懷推播?

長輩獨自在家時,常因忘記或看錯藥袋說明而重複吃藥或漏吃。透過 LINE Messaging API 將解析結果即時推播給家屬有好處。

家屬無須額外下載專屬 App,透過台灣普及率最高的 LINE 就能掌握長輩用藥動態。結構化 Flex Message 卡片以清晰的圖文呈現「白話文摘要」與「警語提醒」,方便家屬快速瀏覽並在必要時打電話關心長輩。

二、 編寫 LINE 推播與 API 整合邏輯(app.py)

開啟專案根目錄下的 app.py,更新為以下完整程式碼:

import os
import json
import uuid
import sqlite3
from flask import Flask, request, jsonify, send_from_directory
from dotenv import load_dotenv
from google import genai
from google.genai import types
from PIL import Image
from gtts import gTTS

# LINE Bot SDK 引入
from linebot.v3.messaging import (
    Configuration,
    ApiClient,
    MessagingApi,
    PushMessageRequest,
    TextMessage,
    FlexMessage,
    FlexContainer
)

# 1. 載入環境變數與初始化
load_dotenv()
api_key = os.getenv("GEMINI_API_KEY")
line_access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
line_user_id = os.getenv("LINE_USER_ID")  # 家屬的 LINE User ID

if not api_key:
    raise ValueError("❌ 錯誤:找不到 GEMINI_API_KEY,請檢查 .env 設定!")

client = genai.Client(api_key=api_key)

# 初始化 LINE Messaging API Client
if line_access_token:
    configuration = Configuration(access_token=line_access_token)
    line_api_client = ApiClient(configuration)
    line_bot_api = MessagingApi(line_api_client)
else:
    line_bot_api = None

app = Flask(__name__)
app.json.ensure_ascii = False  # 讓 Flask 回傳漂亮的繁體中文 JSON

# 設定檔案儲存目錄與資料庫路徑
AUDIO_DIR = os.path.join(os.getcwd(), 'static', 'audio')
DATABASE_PATH = os.path.join(os.getcwd(), 'prescription_vlm.db')
os.makedirs(AUDIO_DIR, exist_ok=True)

# 2. 資料庫初始化
def init_db():
    conn = sqlite3.connect(DATABASE_PATH)
    cursor = conn.cursor()
    
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS prescriptions (
            id TEXT PRIMARY KEY,
            spoken_summary TEXT NOT NULL,
            audio_url TEXT NOT NULL,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')
    
    cursor.execute('''
        CREATE TABLE IF NOT EXISTS medicines (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            prescription_id TEXT NOT NULL,
            name TEXT NOT NULL,
            type TEXT NOT NULL,
            frequency TEXT NOT NULL,
            dosage TEXT NOT NULL,
            timing TEXT NOT NULL,
            warning TEXT,
            FOREIGN KEY (prescription_id) REFERENCES prescriptions (id)
        )
    ''')
    
    conn.commit()
    conn.close()

init_db()

# 3. 傳送 LINE 關懷推播函式
def send_line_notification(summary, medicines_count):
    if not line_bot_api or not line_user_id:
        print("⚠️ 尚未設定 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_USER_ID,跳過推播。")
        return

    push_text = f"💊【長輩用藥通知】\n剛才已完成藥袋辨識,共有 {medicines_count} 種藥品。\n\n白話摘要:\n{summary}"

    try:
        push_message_request = PushMessageRequest(
            to=line_user_id,
            messages=[TextMessage(text=push_text)]
        )
        line_bot_api.push_message(push_message_request)
        print("✅ LINE 關懷推播發送成功!")
    except Exception as e:
        print(f"❌ LINE 推播發送失敗: {str(e)}")

# 4. 定義 JSON Schema
prescription_schema = {
    "type": "OBJECT",
    "properties": {
        "spoken_summary": {
            "type": "STRING",
            "description": "適合唸給長輩聽的白話文總結,語氣溫柔親切。"
        },
        "medicines": {
            "type": "ARRAY",
            "items": {
                "type": "OBJECT",
                "properties": {
                    "name": {"type": "STRING", "description": "藥品中文名稱或商品名"},
                    "type": {"type": "STRING", "description": "口服或外用"},
                    "frequency": {"type": "STRING", "description": "服用頻率,如:每日二次"},
                    "dosage": {"type": "STRING", "description": "每次劑量,如:1顆、0.5顆"},
                    "timing": {"type": "STRING", "description": "吃藥時間點,如:飯後、睡前"},
                    "warning": {"type": "STRING", "description": "重要警語或注意事項,無則填空字串"}
                },
                "required": ["name", "type", "frequency", "dosage", "timing"]
            }
        }
    },
    "required": ["spoken_summary", "medicines"]
}

@app.route('/ping', methods=['GET'])
def ping():
    return jsonify({"status": "online", "service": "PrescriptionVLM Engine"}), 200

@app.route('/analyze-prescription', methods=['POST'])
def analyze_prescription():
    if 'image' not in request.files:
        return jsonify({"error": "未提供圖片檔案 (key 名稱應為 'image')"}), 400

    file = request.files['image']
    if file.filename == '':
        return jsonify({"error": "未選擇上傳的圖片"}), 400

    try:
        image = Image.open(file.stream)
        prompt = """
        你是一位專業且細心的藥師助手。請分析這張藥袋照片:
        1. 將藥品分類為口服或外用,並精準提取名稱、頻率、劑量與吃藥時間。
        2. 針對高齡長輩,撰寫一段溫柔白話的 spoken_summary,適合 TTS 語音播報。
        3. 若有重要注意事項,請填入 warning 欄位。
        """

        config = types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=prescription_schema
        )

        response = client.models.generate_content(
            model='gemini-3.6-flash',
            contents=[image, prompt],
            config=config
        )

        result_data = json.loads(response.text)

        spoken_text = result_data.get("spoken_summary", "解析完成。")
        prescription_id = uuid.uuid4().hex[:8]
        filename = f"speech_{prescription_id}.mp3"
        filepath = os.path.join(AUDIO_DIR, filename)

        tts = gTTS(text=spoken_text, lang='zh-tw')
        tts.save(filepath)

        audio_url = f"/static/audio/{filename}"
        result_data['audio_url'] = audio_url
        result_data['id'] = prescription_id

        # 寫入 SQLite
        conn = sqlite3.connect(DATABASE_PATH)
        cursor = conn.cursor()

        cursor.execute(
            "INSERT INTO prescriptions (id, spoken_summary, audio_url) VALUES (?, ?, ?)",
            (prescription_id, spoken_text, audio_url)
        )

        meds = result_data.get("medicines", [])
        for med in meds:
            cursor.execute(
                """INSERT INTO medicines 
                   (prescription_id, name, type, frequency, dosage, timing, warning) 
                   VALUES (?, ?, ?, ?, ?, ?, ?)""",
                (
                    prescription_id,
                    med.get("name"),
                    med.get("type"),
                    med.get("frequency"),
                    med.get("dosage"),
                    med.get("timing"),
                    med.get("warning", "")
                )
            )

        conn.commit()
        conn.close()

        # 觸發 LINE 關懷推播通知家屬
        send_line_notification(spoken_text, len(meds))

        return jsonify({
            "status": "success",
            "data": result_data
        }), 200

    except Exception as e:
        return jsonify({"error": f"伺服器處理失敗: {str(e)}"}), 500

@app.route('/prescriptions', methods=['GET'])
def get_prescriptions():
    try:
        conn = sqlite3.connect(DATABASE_PATH)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        cursor.execute("SELECT * FROM prescriptions ORDER BY created_at DESC")
        prescriptions = cursor.fetchall()

        history = []
        for p in prescriptions:
            cursor.execute("SELECT name, type, frequency, dosage, timing, warning FROM medicines WHERE prescription_id = ?", (p['id'],))
            meds = [dict(m) for m in cursor.fetchall()]

            history.append({
                "id": p['id'],
                "spoken_summary": p['spoken_summary'],
                "audio_url": p['audio_url'],
                "created_at": p['created_at'],
                "medicines": meds
            })

        conn.close()
        return jsonify({
            "status": "success",
            "data": history
        }), 200

    except Exception as e:
        return jsonify({"error": f"查詢失敗: {str(e)}"}), 500

@app.route('/static/audio/<filename>', methods=['GET'])
def get_audio(filename):
    return send_from_directory(AUDIO_DIR, filename)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

三、 環境變數與 SDK 安裝

1.安裝 LINE Bot SDK

在 Terminal 虛擬環境中安裝官方套件:

pip install line-bot-sdk

2.設定 .env 檔案

在專案根目錄的 .env 中加入取得的 Token 與 USER_ID:

GEMINI_API_KEY=your_gemini_api_key
LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token
LINE_USER_ID=your_line_user_id

如果在測試階段尚未申請 LINE Messaging API Token,可以暫時把 .env 中的 LINE_CHANNEL_ACCESS_TOKEN 與 LINE_USER_ID 留空,例如(LINE_USER_ID="")
,程式碼會自動觸發防呆機制,印出 ⚠️ 尚未設定 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_USER_ID,跳過推播,不會影響原有的藥袋辨識與 SQLite 寫入流程。

四、 API 測試與成果驗證

啟動 Flask 後端服務

於 Terminal 執行,輸入(Ctrl+C 可取消執行 app.py):

python app.py

發送藥袋圖片測試

開啟新的 Terminal 分頁執行 curl 測試上傳:

curl -X POST http://127.0.0.1:5000/analyze-prescription \
  -F "image=@test_rx.jpg"

預期輸出結果

  • API 正常回傳 JSON 成果與語音檔 URL
  • Terminal 顯示 ✅ LINE 關懷推播發送成功
  • 家屬的 LINE 應用程式即時收到帶有藥品總數與白話文摘要的推播訊息

五、 版本控制與提交 GitHub

測試成功後,提交 Day 08 程式碼:

git add .
git commit -m "保留雙引號 改填寫自己要記錄的標記 ex.鐵人賽第八天"
git push

六、 本日小結與明日預告

今天整合 LINE Messaging API,完成長輩用藥與家屬端的遠端訊息推播。當長輩完成藥袋掃描時,家屬就能即時掌握用藥資訊。

明天(Day 09)進行用藥安全過濾與警語檢測。在多重用藥情境中自動偵測重覆藥性與高風險交互作用。


上一篇
Day 07|別讓 AI 變金魚腦!SQLite 資料庫整合與用藥歷程持久化
下一篇
Day 09|跨科開藥不怕吃錯!用 Gemini VLM 實作重複藥性與高風險警語檢測
系列文
給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言